/*
 * Copyright (C) 2013 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.aplink.generic.google.datetimepicker.time;

import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.content.Context;
import android.content.res.Resources;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.text.format.DateUtils;
import android.text.format.Time;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnTouchListener;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityManager;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.FrameLayout;

import com.aplink.generic.google.datetimepicker.HapticFeedbackController;
import com.aplink.sg.R;

/**
 * The primary layout to hold the circular picker, and the am/pm buttons. This
 * view well measure itself to end up as a square. It also handles touches to be
 * passed in to views that need to know when they'd been touched.
 */
public class RadialPickerLayout extends FrameLayout implements OnTouchListener {
	public interface OnValueSelectedListener {
		void onValueSelected(int pickerIndex, int newValue, boolean autoAdvance);
	}

	private static final int AM = TimePickerDialog.AM;
	private static final int AMPM_INDEX = TimePickerDialog.AMPM_INDEX;
	private static final int ENABLE_PICKER_INDEX = TimePickerDialog.ENABLE_PICKER_INDEX;
	private static final int HOUR_INDEX = TimePickerDialog.HOUR_INDEX;
	private static final int HOUR_VALUE_TO_DEGREES_STEP_SIZE = RadialPickerLayout.VISIBLE_DEGREES_STEP_SIZE;
	private static final int MINUTE_INDEX = TimePickerDialog.MINUTE_INDEX;
	private static final int MINUTE_VALUE_TO_DEGREES_STEP_SIZE = 6;
	private static final int PM = TimePickerDialog.PM;
	private static final String TAG = "RadialPickerLayout";
	private static final int VISIBLE_DEGREES_STEP_SIZE = 30;

	/**
	 * Returns mapping of any input degrees (0 to 360) to one of 12 visible
	 * output degrees (all multiples of 30), where the input will be "snapped"
	 * to the closest visible degrees.
	 *
	 * @param degrees
	 *            The input degrees
	 * @param forceAboveOrBelow
	 *            The output may be forced to either the higher or lower step,
	 *            or may be allowed to snap to whichever is closer. Use 1 to
	 *            force strictly higher, -1 to force strictly lower, and 0 to
	 *            snap to the closer one.
	 * @return output degrees, will be a multiple of 30
	 */
	private static int snapOnly30s(int degrees, final int forceHigherOrLower) {
		final int stepSize = RadialPickerLayout.HOUR_VALUE_TO_DEGREES_STEP_SIZE;
		int floor = (degrees / stepSize) * stepSize;
		final int ceiling = floor + stepSize;
		if (forceHigherOrLower == 1) {
			degrees = ceiling;
		} else if (forceHigherOrLower == -1) {
			if (degrees == floor) {
				floor -= stepSize;
			}
			degrees = floor;
		} else {
			if ((degrees - floor) < (ceiling - degrees)) {
				degrees = floor;
			} else {
				degrees = ceiling;
			}
		}
		return degrees;
	}

	private final AccessibilityManager mAccessibilityManager;

	private final AmPmCirclesView mAmPmCirclesView;
	private final CircleView mCircleView;
	private int mCurrentHoursOfDay;
	private int mCurrentItemShowing;
	private int mCurrentMinutes;
	private boolean mDoingMove;
	private boolean mDoingTouch;
	private int mDownDegrees;

	private float mDownX;
	private float mDownY;
	private final View mGrayBox;
	private final Handler mHandler = new Handler();
	private HapticFeedbackController mHapticFeedbackController;
	private boolean mHideAmPm;
	private final RadialSelectorView mHourRadialSelectorView;

	private final RadialTextsView mHourRadialTextsView;
	private boolean mInputEnabled;
	private boolean mIs24HourMode;
	private int mIsTouchingAmOrPm = -1;
	private int mLastValueSelected;
	private OnValueSelectedListener mListener;
	private final RadialSelectorView mMinuteRadialSelectorView;
	private final RadialTextsView mMinuteRadialTextsView;
	private int[] mSnapPrefer30sMap;

	private boolean mTimeInitialized;
	private AnimatorSet mTransition;

	private final int TAP_TIMEOUT;

	private final int TOUCH_SLOP;

	public RadialPickerLayout(final Context context, final AttributeSet attrs) {
		super(context, attrs);

		setOnTouchListener(this);
		final ViewConfiguration vc = ViewConfiguration.get(context);
		this.TOUCH_SLOP = vc.getScaledTouchSlop();
		this.TAP_TIMEOUT = ViewConfiguration.getTapTimeout();
		this.mDoingMove = false;

		this.mCircleView = new CircleView(context);
		this.addView(this.mCircleView);

		this.mAmPmCirclesView = new AmPmCirclesView(context);
		this.addView(this.mAmPmCirclesView);

		this.mHourRadialTextsView = new RadialTextsView(context);
		this.addView(this.mHourRadialTextsView);
		this.mMinuteRadialTextsView = new RadialTextsView(context);
		this.addView(this.mMinuteRadialTextsView);

		this.mHourRadialSelectorView = new RadialSelectorView(context);
		this.addView(this.mHourRadialSelectorView);
		this.mMinuteRadialSelectorView = new RadialSelectorView(context);
		this.addView(this.mMinuteRadialSelectorView);

		// Prepare mapping to snap touchable degrees to selectable degrees.
		preparePrefer30sMap();

		this.mLastValueSelected = -1;

		this.mInputEnabled = true;
		this.mGrayBox = new View(context);
		this.mGrayBox.setLayoutParams(new ViewGroup.LayoutParams(
				ViewGroup.LayoutParams.MATCH_PARENT,
				ViewGroup.LayoutParams.MATCH_PARENT));
		this.mGrayBox.setBackgroundColor(getResources().getColor(
				R.color.transparent_black));
		this.mGrayBox.setVisibility(View.INVISIBLE);
		this.addView(this.mGrayBox);

		this.mAccessibilityManager = (AccessibilityManager) context
				.getSystemService(Context.ACCESSIBILITY_SERVICE);

		this.mTimeInitialized = false;
	}

	/**
	 * Announce the currently-selected time when launched.
	 */
	@SuppressWarnings("deprecation")
	@Override
	public boolean dispatchPopulateAccessibilityEvent(
			final AccessibilityEvent event) {
		if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
			// Clear the event's current text so that only the current time will
			// be spoken.
			event.getText().clear();
			final Time time = new Time();
			time.hour = getHours();
			time.minute = getMinutes();
			final long millis = time.normalize(true);
			int flags = DateUtils.FORMAT_SHOW_TIME;
			if (this.mIs24HourMode) {
				flags |= DateUtils.FORMAT_24HOUR;
			}
			final String timeString = DateUtils.formatDateTime(getContext(),
					millis, flags);
			event.getText().add(timeString);
			return true;
		}
		return super.dispatchPopulateAccessibilityEvent(event);
	}

	/**
	 * Get the item (hours or minutes) that is currently showing.
	 */
	public int getCurrentItemShowing() {
		if ((this.mCurrentItemShowing != RadialPickerLayout.HOUR_INDEX)
				&& (this.mCurrentItemShowing != RadialPickerLayout.MINUTE_INDEX)) {
			Log.e(RadialPickerLayout.TAG,
					"Current item showing was unfortunately set to "
							+ this.mCurrentItemShowing);
			return -1;
		}
		return this.mCurrentItemShowing;
	}

	/**
	 * If the hours are showing, return the current hour. If the minutes are
	 * showing, return the current minute.
	 */
	private int getCurrentlyShowingValue() {
		final int currentIndex = getCurrentItemShowing();
		if (currentIndex == RadialPickerLayout.HOUR_INDEX) {
			return this.mCurrentHoursOfDay;
		} else if (currentIndex == RadialPickerLayout.MINUTE_INDEX) {
			return this.mCurrentMinutes;
		} else {
			return -1;
		}
	}

	/**
	 * Calculate the degrees within the circle that corresponds to the specified
	 * coordinates, if the coordinates are within the range that will trigger a
	 * selection.
	 *
	 * @param pointX
	 *            The x coordinate.
	 * @param pointY
	 *            The y coordinate.
	 * @param forceLegal
	 *            Force the selection to be legal, regardless of how far the
	 *            coordinates are from the actual numbers.
	 * @param isInnerCircle
	 *            If the selection may be in the inner circle, pass in a size-1
	 *            boolean array here, inside which the value will be true if the
	 *            selection is in the inner circle, and false if in the outer
	 *            circle.
	 * @return Degrees from 0 to 360, if the selection was within the legal
	 *         range. -1 if not.
	 */
	private int getDegreesFromCoords(final float pointX, final float pointY,
			final boolean forceLegal, final Boolean[] isInnerCircle) {
		final int currentItem = getCurrentItemShowing();
		if (currentItem == RadialPickerLayout.HOUR_INDEX) {
			return this.mHourRadialSelectorView.getDegreesFromCoords(pointX,
					pointY, forceLegal, isInnerCircle);
		} else if (currentItem == RadialPickerLayout.MINUTE_INDEX) {
			return this.mMinuteRadialSelectorView.getDegreesFromCoords(pointX,
					pointY, forceLegal, isInnerCircle);
		} else {
			return -1;
		}
	}

	public int getHours() {
		return this.mCurrentHoursOfDay;
	}

	public int getIsCurrentlyAmOrPm() {
		if (this.mCurrentHoursOfDay < 12) {
			return RadialPickerLayout.AM;
		} else if (this.mCurrentHoursOfDay < 24) {
			return RadialPickerLayout.PM;
		}
		return -1;
	}

	public int getMinutes() {
		return this.mCurrentMinutes;
	}

	/**
	 * Initialize the Layout with starting values.
	 *
	 * @param context
	 * @param initialHoursOfDay
	 * @param initialMinutes
	 * @param is24HourMode
	 */
	public void initialize(final Context context,
			final HapticFeedbackController hapticFeedbackController,
			final int initialHoursOfDay, final int initialMinutes,
			final boolean is24HourMode) {
		if (this.mTimeInitialized) {
			Log.e(RadialPickerLayout.TAG, "Time has already been initialized.");
			return;
		}

		this.mHapticFeedbackController = hapticFeedbackController;
		this.mIs24HourMode = is24HourMode;
		this.mHideAmPm = this.mAccessibilityManager.isTouchExplorationEnabled() ? true
				: this.mIs24HourMode;

		// Initialize the circle and AM/PM circles if applicable.
		this.mCircleView.initialize(context, this.mHideAmPm);
		this.mCircleView.invalidate();
		if (!this.mHideAmPm) {
			this.mAmPmCirclesView.initialize(context,
					initialHoursOfDay < 12 ? RadialPickerLayout.AM
							: RadialPickerLayout.PM);
			this.mAmPmCirclesView.invalidate();
		}

		// Initialize the hours and minutes numbers.
		final Resources res = context.getResources();
		final int[] hours = { 12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 };
		final int[] hours_24 = { 0, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23 };
		final int[] minutes = { 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55 };
		final String[] hoursTexts = new String[12];
		final String[] innerHoursTexts = new String[12];
		final String[] minutesTexts = new String[12];
		for (int i = 0; i < 12; i++) {
			hoursTexts[i] = is24HourMode ? String.format("%02d", hours_24[i])
					: String.format("%d", hours[i]);
			innerHoursTexts[i] = String.format("%d", hours[i]);
			minutesTexts[i] = String.format("%02d", minutes[i]);
		}
		this.mHourRadialTextsView.initialize(res, hoursTexts,
				(is24HourMode ? innerHoursTexts : null), this.mHideAmPm, true);
		this.mHourRadialTextsView.invalidate();
		this.mMinuteRadialTextsView.initialize(res, minutesTexts, null,
				this.mHideAmPm, false);
		this.mMinuteRadialTextsView.invalidate();

		// Initialize the currently-selected hour and minute.
		setValueForItem(RadialPickerLayout.HOUR_INDEX, initialHoursOfDay);
		setValueForItem(RadialPickerLayout.MINUTE_INDEX, initialMinutes);
		final int hourDegrees = (initialHoursOfDay % 12)
				* RadialPickerLayout.HOUR_VALUE_TO_DEGREES_STEP_SIZE;
		this.mHourRadialSelectorView.initialize(context, this.mHideAmPm,
				is24HourMode, true, hourDegrees,
				isHourInnerCircle(initialHoursOfDay));
		final int minuteDegrees = initialMinutes
				* RadialPickerLayout.MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
		this.mMinuteRadialSelectorView.initialize(context, this.mHideAmPm,
				false, false, minuteDegrees, false);

		this.mTimeInitialized = true;
	}

	/**
	 * Check if a given hour appears in the outer circle or the inner circle
	 *
	 * @return true if the hour is in the inner circle, false if it's in the
	 *         outer circle.
	 */
	private boolean isHourInnerCircle(final int hourOfDay) {
		// We'll have the 00 hours on the outside circle.
		return this.mIs24HourMode && ((hourOfDay <= 12) && (hourOfDay != 0));
	}

	/**
	 * Necessary for accessibility, to ensure we support "scrolling" forward and
	 * backward in the circle.
	 */
	@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
	@Override
	public void onInitializeAccessibilityNodeInfo(
			final AccessibilityNodeInfo info) {
		super.onInitializeAccessibilityNodeInfo(info);
		info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
		info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
	}

	/**
	 * Measure the view to end up as a square, based on the minimum of the
	 * height and width.
	 */
	@Override
	public void onMeasure(final int widthMeasureSpec,
			final int heightMeasureSpec) {
		final int measuredWidth = MeasureSpec.getSize(widthMeasureSpec);
		final int widthMode = MeasureSpec.getMode(widthMeasureSpec);
		final int measuredHeight = MeasureSpec.getSize(heightMeasureSpec);
		final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
		final int minDimension = Math.min(measuredWidth, measuredHeight);

		super.onMeasure(MeasureSpec.makeMeasureSpec(minDimension, widthMode),
				MeasureSpec.makeMeasureSpec(minDimension, heightMode));
	}

	@Override
	public boolean onTouch(final View v, final MotionEvent event) {
		final float eventX = event.getX();
		final float eventY = event.getY();
		int degrees;
		int value;
		final Boolean[] isInnerCircle = new Boolean[1];
		isInnerCircle[0] = false;

		switch (event.getAction()) {
			case MotionEvent.ACTION_DOWN:
				if (!this.mInputEnabled) {
					return true;
				}

				this.mDownX = eventX;
				this.mDownY = eventY;

				this.mLastValueSelected = -1;
				this.mDoingMove = false;
				this.mDoingTouch = true;
				// If we're showing the AM/PM, check to see if the user is
				// touching it.
				if (!this.mHideAmPm) {
					this.mIsTouchingAmOrPm = this.mAmPmCirclesView
					        .getIsTouchingAmOrPm(eventX, eventY);
				} else {
					this.mIsTouchingAmOrPm = -1;
				}
				if ((this.mIsTouchingAmOrPm == RadialPickerLayout.AM)
				        || (this.mIsTouchingAmOrPm == RadialPickerLayout.PM)) {
					// If the touch is on AM or PM, set it as "touched" after
					// the TAP_TIMEOUT
					// in case the user moves their finger quickly.
					this.mHapticFeedbackController.tryVibrate();
					this.mDownDegrees = -1;
					this.mHandler.postDelayed(new Runnable() {
						@Override
						public void run() {
							RadialPickerLayout.this.mAmPmCirclesView
							        .setAmOrPmPressed(RadialPickerLayout.this.mIsTouchingAmOrPm);
							RadialPickerLayout.this.mAmPmCirclesView
							        .invalidate();
						}
					}, this.TAP_TIMEOUT);
				} else {
					// If we're in accessibility mode, force the touch to be
					// legal. Otherwise,
					// it will only register within the given touch target zone.
					final boolean forceLegal = this.mAccessibilityManager
					        .isTouchExplorationEnabled();
					// Calculate the degrees that is currently being touched.
					this.mDownDegrees = getDegreesFromCoords(eventX, eventY,
					        forceLegal, isInnerCircle);
					if (this.mDownDegrees != -1) {
						// If it's a legal touch, set that number as "selected"
						// after the
						// TAP_TIMEOUT in case the user moves their finger
						// quickly.
						this.mHapticFeedbackController.tryVibrate();
						this.mHandler.postDelayed(new Runnable() {
							@Override
							public void run() {
								RadialPickerLayout.this.mDoingMove = true;
								final int value = RadialPickerLayout.this
								        .reselectSelector(
								                RadialPickerLayout.this.mDownDegrees,
								                isInnerCircle[0], false, true);
								RadialPickerLayout.this.mLastValueSelected = value;
								RadialPickerLayout.this.mListener
								        .onValueSelected(
								                RadialPickerLayout.this
								                        .getCurrentItemShowing(),
								                value, false);
							}
						}, this.TAP_TIMEOUT);
					}
				}
				return true;
			case MotionEvent.ACTION_MOVE:
				if (!this.mInputEnabled) {
					// We shouldn't be in this state, because input is disabled.
					Log.e(RadialPickerLayout.TAG,
					        "Input was disabled, but received ACTION_MOVE.");
					return true;
				}

				final float dY = Math.abs(eventY - this.mDownY);
				final float dX = Math.abs(eventX - this.mDownX);

				if (!this.mDoingMove && (dX <= this.TOUCH_SLOP)
				        && (dY <= this.TOUCH_SLOP)) {
					// Hasn't registered down yet, just slight, accidental
					// movement of finger.
					break;
				}

				// If we're in the middle of touching down on AM or PM, check if
				// we still are.
				// If so, no-op. If not, remove its pressed state. Either way,
				// no need to check
				// for touches on the other circle.
				if ((this.mIsTouchingAmOrPm == RadialPickerLayout.AM)
				        || (this.mIsTouchingAmOrPm == RadialPickerLayout.PM)) {
					this.mHandler.removeCallbacksAndMessages(null);
					final int isTouchingAmOrPm = this.mAmPmCirclesView
					        .getIsTouchingAmOrPm(eventX, eventY);
					if (isTouchingAmOrPm != this.mIsTouchingAmOrPm) {
						this.mAmPmCirclesView.setAmOrPmPressed(-1);
						this.mAmPmCirclesView.invalidate();
						this.mIsTouchingAmOrPm = -1;
					}
					break;
				}

				if (this.mDownDegrees == -1) {
					// Original down was illegal, so no movement will register.
					break;
				}

				// We're doing a move along the circle, so move the selection as
				// appropriate.
				this.mDoingMove = true;
				this.mHandler.removeCallbacksAndMessages(null);
				degrees = getDegreesFromCoords(eventX, eventY, true,
				        isInnerCircle);
				if (degrees != -1) {
					value = reselectSelector(degrees, isInnerCircle[0], false,
					        true);
					if (value != this.mLastValueSelected) {
						this.mHapticFeedbackController.tryVibrate();
						this.mLastValueSelected = value;
						this.mListener.onValueSelected(getCurrentItemShowing(),
						        value, false);
					}
				}
				return true;
			case MotionEvent.ACTION_UP:
				if (!this.mInputEnabled) {
					// If our touch input was disabled, tell the listener to
					// re-enable us.
					Log.d(RadialPickerLayout.TAG,
					        "Input was disabled, but received ACTION_UP.");
					this.mListener.onValueSelected(
					        RadialPickerLayout.ENABLE_PICKER_INDEX, 1, false);
					return true;
				}

				this.mHandler.removeCallbacksAndMessages(null);
				this.mDoingTouch = false;

				// If we're touching AM or PM, set it as selected, and tell the
				// listener.
				if ((this.mIsTouchingAmOrPm == RadialPickerLayout.AM)
				        || (this.mIsTouchingAmOrPm == RadialPickerLayout.PM)) {
					final int isTouchingAmOrPm = this.mAmPmCirclesView
					        .getIsTouchingAmOrPm(eventX, eventY);
					this.mAmPmCirclesView.setAmOrPmPressed(-1);
					this.mAmPmCirclesView.invalidate();

					if (isTouchingAmOrPm == this.mIsTouchingAmOrPm) {
						this.mAmPmCirclesView.setAmOrPm(isTouchingAmOrPm);
						if (getIsCurrentlyAmOrPm() != isTouchingAmOrPm) {
							this.mListener.onValueSelected(
							        RadialPickerLayout.AMPM_INDEX,
							        this.mIsTouchingAmOrPm, false);
							setValueForItem(RadialPickerLayout.AMPM_INDEX,
							        isTouchingAmOrPm);
						}
					}
					this.mIsTouchingAmOrPm = -1;
					break;
				}

				// If we have a legal degrees selected, set the value and tell
				// the listener.
				if (this.mDownDegrees != -1) {
					degrees = getDegreesFromCoords(eventX, eventY,
					        this.mDoingMove, isInnerCircle);
					if (degrees != -1) {
						value = reselectSelector(degrees, isInnerCircle[0],
						        !this.mDoingMove, false);
						if ((getCurrentItemShowing() == RadialPickerLayout.HOUR_INDEX)
						        && !this.mIs24HourMode) {
							final int amOrPm = getIsCurrentlyAmOrPm();
							if ((amOrPm == RadialPickerLayout.AM)
							        && (value == 12)) {
								value = 0;
							} else if ((amOrPm == RadialPickerLayout.PM)
							        && (value != 12)) {
								value += 12;
							}
						}
						setValueForItem(getCurrentItemShowing(), value);
						this.mListener.onValueSelected(getCurrentItemShowing(),
						        value, true);
					}
				}
				this.mDoingMove = false;
				return true;
			default:
				break;
		}
		return false;
	}

	/**
	 * When scroll forward/backward events are received, jump the time to the
	 * higher/lower discrete, visible value on the circle.
	 */
	@SuppressLint("NewApi")
	@Override
	public boolean performAccessibilityAction(final int action,
			final Bundle arguments) {
		if (super.performAccessibilityAction(action, arguments)) {
			return true;
		}

		int changeMultiplier = 0;
		if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) {
			changeMultiplier = 1;
		} else if (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
			changeMultiplier = -1;
		}
		if (changeMultiplier != 0) {
			int value = getCurrentlyShowingValue();
			int stepSize = 0;
			final int currentItemShowing = getCurrentItemShowing();
			if (currentItemShowing == RadialPickerLayout.HOUR_INDEX) {
				stepSize = RadialPickerLayout.HOUR_VALUE_TO_DEGREES_STEP_SIZE;
				value %= 12;
			} else if (currentItemShowing == RadialPickerLayout.MINUTE_INDEX) {
				stepSize = RadialPickerLayout.MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
			}

			int degrees = value * stepSize;
			degrees = RadialPickerLayout.snapOnly30s(degrees, changeMultiplier);
			value = degrees / stepSize;
			int maxValue = 0;
			int minValue = 0;
			if (currentItemShowing == RadialPickerLayout.HOUR_INDEX) {
				if (this.mIs24HourMode) {
					maxValue = 23;
				} else {
					maxValue = 12;
					minValue = 1;
				}
			} else {
				maxValue = 55;
			}
			if (value > maxValue) {
				// If we scrolled forward past the highest number, wrap around
				// to the lowest.
				value = minValue;
			} else if (value < minValue) {
				// If we scrolled backward past the lowest number, wrap around
				// to the highest.
				value = maxValue;
			}
			setItem(currentItemShowing, value);
			this.mListener.onValueSelected(currentItemShowing, value, false);
			return true;
		}

		return false;
	}

	/**
	 * Split up the 360 degrees of the circle among the 60 selectable values.
	 * Assigns a larger selectable area to each of the 12 visible values, such
	 * that the ratio of space apportioned to a visible value : space
	 * apportioned to a non-visible value will be 14 : 4. E.g. the output of 30
	 * degrees should have a higher range of input associated with it than the
	 * output of 24 degrees, because 30 degrees corresponds to a visible number
	 * on the clock circle (5 on the minutes, 1 or 13 on the hours).
	 */
	private void preparePrefer30sMap() {
		// We'll split up the visible output and the non-visible output such
		// that each visible
		// output will correspond to a range of 14 associated input degrees, and
		// each non-visible
		// output will correspond to a range of 4 associate input degrees, so
		// visible numbers
		// are more than 3 times easier to get than non-visible numbers:
		// {354-359,0-7}:0, {8-11}:6, {12-15}:12, {16-19}:18, {20-23}:24,
		// {24-37}:30, etc.
		//
		// If an output of 30 degrees should correspond to a range of 14
		// associated degrees, then
		// we'll need any input between 24 - 37 to snap to 30. Working out from
		// there, 20-23 should
		// snap to 24, while 38-41 should snap to 36. This is somewhat
		// counter-intuitive, that you
		// can be touching 36 degrees but have the selection snapped to 30
		// degrees; however, this
		// inconsistency isn't noticeable at such fine-grained degrees, and it
		// affords us the
		// ability to aggressively prefer the visible values by a factor of more
		// than 3:1, which
		// greatly contributes to the selectability of these values.

		// Our input will be 0 through 360.
		this.mSnapPrefer30sMap = new int[361];

		// The first output is 0, and each following output will increment by 6
		// {0, 6, 12, ...}.
		int snappedOutputDegrees = 0;
		// Count of how many inputs we've designated to the specified output.
		int count = 1;
		// How many input we expect for a specified output. This will be 14 for
		// output divisible
		// by 30, and 4 for the remaining output. We'll special case the outputs
		// of 0 and 360, so
		// the caller can decide which they need.
		int expectedCount = 8;
		// Iterate through the input.
		for (int degrees = 0; degrees < 361; degrees++) {
			// Save the input-output mapping.
			this.mSnapPrefer30sMap[degrees] = snappedOutputDegrees;
			// If this is the last input for the specified output, calculate the
			// next output and
			// the next expected count.
			if (count == expectedCount) {
				snappedOutputDegrees += 6;
				if (snappedOutputDegrees == 360) {
					expectedCount = 7;
				} else if ((snappedOutputDegrees % 30) == 0) {
					expectedCount = 14;
				} else {
					expectedCount = 4;
				}
				count = 1;
			} else {
				count++;
			}
		}
	}

	/**
	 * For the currently showing view (either hours or minutes), re-calculate
	 * the position for the selector, and redraw it at that position. The input
	 * degrees will be snapped to a selectable value.
	 *
	 * @param degrees
	 *            Degrees which should be selected.
	 * @param isInnerCircle
	 *            Whether the selection should be in the inner circle; will be
	 *            ignored if there is no inner circle.
	 * @param forceToVisibleValue
	 *            Even if the currently-showing circle allows for fine-grained
	 *            selection (i.e. minutes), force the selection to one of the
	 *            visibly-showing values.
	 * @param forceDrawDot
	 *            The dot in the circle will generally only be shown when the
	 *            selection is on non-visible values, but use this to force the
	 *            dot to be shown.
	 * @return The value that was selected, i.e. 0-23 for hours, 0-59 for
	 *         minutes.
	 */
	private int reselectSelector(int degrees, final boolean isInnerCircle,
			final boolean forceToVisibleValue, final boolean forceDrawDot) {
		if (degrees == -1) {
			return -1;
		}
		final int currentShowing = getCurrentItemShowing();

		int stepSize;
		final boolean allowFineGrained = !forceToVisibleValue
				&& (currentShowing == RadialPickerLayout.MINUTE_INDEX);
		if (allowFineGrained) {
			degrees = snapPrefer30s(degrees);
		} else {
			degrees = RadialPickerLayout.snapOnly30s(degrees, 0);
		}

		RadialSelectorView radialSelectorView;
		if (currentShowing == RadialPickerLayout.HOUR_INDEX) {
			radialSelectorView = this.mHourRadialSelectorView;
			stepSize = RadialPickerLayout.HOUR_VALUE_TO_DEGREES_STEP_SIZE;
		} else {
			radialSelectorView = this.mMinuteRadialSelectorView;
			stepSize = RadialPickerLayout.MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
		}
		radialSelectorView.setSelection(degrees, isInnerCircle, forceDrawDot);
		radialSelectorView.invalidate();

		if (currentShowing == RadialPickerLayout.HOUR_INDEX) {
			if (this.mIs24HourMode) {
				if ((degrees == 0) && isInnerCircle) {
					degrees = 360;
				} else if ((degrees == 360) && !isInnerCircle) {
					degrees = 0;
				}
			} else if (degrees == 0) {
				degrees = 360;
			}
		} else if ((degrees == 360)
				&& (currentShowing == RadialPickerLayout.MINUTE_INDEX)) {
			degrees = 0;
		}

		int value = degrees / stepSize;
		if ((currentShowing == RadialPickerLayout.HOUR_INDEX)
				&& this.mIs24HourMode && !isInnerCircle && (degrees != 0)) {
			value += 12;
		}
		return value;
	}

	/**
	 * Set the internal value as either AM or PM, and update the AM/PM circle
	 * displays.
	 *
	 * @param amOrPm
	 */
	public void setAmOrPm(final int amOrPm) {
		this.mAmPmCirclesView.setAmOrPm(amOrPm);
		this.mAmPmCirclesView.invalidate();
		setValueForItem(RadialPickerLayout.AMPM_INDEX, amOrPm);
	}

	/**
	 * Set either minutes or hours as showing.
	 *
	 * @param animate
	 *            True to animate the transition, false to show with no
	 *            animation.
	 */
	public void setCurrentItemShowing(final int index, final boolean animate) {
		if ((index != RadialPickerLayout.HOUR_INDEX)
				&& (index != RadialPickerLayout.MINUTE_INDEX)) {
			Log.e(RadialPickerLayout.TAG,
					"TimePicker does not support view at index " + index);
			return;
		}

		final int lastIndex = getCurrentItemShowing();
		this.mCurrentItemShowing = index;

		if (animate && (index != lastIndex)) {
			final ObjectAnimator[] anims = new ObjectAnimator[4];
			if (index == RadialPickerLayout.MINUTE_INDEX) {
				anims[0] = this.mHourRadialTextsView.getDisappearAnimator();
				anims[1] = this.mHourRadialSelectorView.getDisappearAnimator();
				anims[2] = this.mMinuteRadialTextsView.getReappearAnimator();
				anims[3] = this.mMinuteRadialSelectorView.getReappearAnimator();
			} else if (index == RadialPickerLayout.HOUR_INDEX) {
				anims[0] = this.mHourRadialTextsView.getReappearAnimator();
				anims[1] = this.mHourRadialSelectorView.getReappearAnimator();
				anims[2] = this.mMinuteRadialTextsView.getDisappearAnimator();
				anims[3] = this.mMinuteRadialSelectorView
						.getDisappearAnimator();
			}

			if ((this.mTransition != null) && this.mTransition.isRunning()) {
				this.mTransition.end();
			}
			this.mTransition = new AnimatorSet();
			this.mTransition.playTogether(anims);
			this.mTransition.start();
		} else {
			final int hourAlpha = (index == RadialPickerLayout.HOUR_INDEX) ? 255
					: 0;
			final int minuteAlpha = (index == RadialPickerLayout.MINUTE_INDEX) ? 255
					: 0;
			this.mHourRadialTextsView.setAlpha(hourAlpha);
			this.mHourRadialSelectorView.setAlpha(hourAlpha);
			this.mMinuteRadialTextsView.setAlpha(minuteAlpha);
			this.mMinuteRadialSelectorView.setAlpha(minuteAlpha);
		}

	}

	/**
	 * Set either the hour or the minute. Will set the internal value, and set
	 * the selection.
	 */
	private void setItem(final int index, final int value) {
		if (index == RadialPickerLayout.HOUR_INDEX) {
			setValueForItem(RadialPickerLayout.HOUR_INDEX, value);
			final int hourDegrees = (value % 12)
					* RadialPickerLayout.HOUR_VALUE_TO_DEGREES_STEP_SIZE;
			this.mHourRadialSelectorView.setSelection(hourDegrees,
					isHourInnerCircle(value), false);
			this.mHourRadialSelectorView.invalidate();
		} else if (index == RadialPickerLayout.MINUTE_INDEX) {
			setValueForItem(RadialPickerLayout.MINUTE_INDEX, value);
			final int minuteDegrees = value
					* RadialPickerLayout.MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
			this.mMinuteRadialSelectorView.setSelection(minuteDegrees, false,
					false);
			this.mMinuteRadialSelectorView.invalidate();
		}
	}

	public void setOnValueSelectedListener(
			final OnValueSelectedListener listener) {
		this.mListener = listener;
	}

	/* package */void setTheme(final Context context, final boolean themeDark) {
		this.mCircleView.setTheme(context, themeDark);
		this.mAmPmCirclesView.setTheme(context, themeDark);
		this.mHourRadialTextsView.setTheme(context, themeDark);
		this.mMinuteRadialTextsView.setTheme(context, themeDark);
		this.mHourRadialSelectorView.setTheme(context, themeDark);
		this.mMinuteRadialSelectorView.setTheme(context, themeDark);
	}

	public void setTime(final int hours, final int minutes) {
		setItem(RadialPickerLayout.HOUR_INDEX, hours);
		setItem(RadialPickerLayout.MINUTE_INDEX, minutes);
	}

	/**
	 * Set the internal value for the hour, minute, or AM/PM.
	 */
	private void setValueForItem(final int index, final int value) {
		if (index == RadialPickerLayout.HOUR_INDEX) {
			this.mCurrentHoursOfDay = value;
		} else if (index == RadialPickerLayout.MINUTE_INDEX) {
			this.mCurrentMinutes = value;
		} else if (index == RadialPickerLayout.AMPM_INDEX) {
			if (value == RadialPickerLayout.AM) {
				this.mCurrentHoursOfDay = this.mCurrentHoursOfDay % 12;
			} else if (value == RadialPickerLayout.PM) {
				this.mCurrentHoursOfDay = (this.mCurrentHoursOfDay % 12) + 12;
			}
		}
	}

	/**
	 * Returns mapping of any input degrees (0 to 360) to one of 60 selectable
	 * output degrees, where the degrees corresponding to visible numbers (i.e.
	 * those divisible by 30) will be weighted heavier than the degrees
	 * corresponding to non-visible numbers. See {@link #preparePrefer30sMap()}
	 * documentation for the rationale and generation of the mapping.
	 */
	private int snapPrefer30s(final int degrees) {
		if (this.mSnapPrefer30sMap == null) {
			return -1;
		}
		return this.mSnapPrefer30sMap[degrees];
	}

	/**
	 * Set touch input as enabled or disabled, for use with keyboard mode.
	 */
	public boolean trySettingInputEnabled(final boolean inputEnabled) {
		if (this.mDoingTouch && !inputEnabled) {
			// If we're trying to disable input, but we're in the middle of a
			// touch event,
			// we'll allow the touch event to continue before disabling input.
			return false;
		}
		this.mInputEnabled = inputEnabled;
		this.mGrayBox.setVisibility(inputEnabled ? View.INVISIBLE
				: View.VISIBLE);
		return true;
	}
}
